baubs git
Commit d813c22df571173ce4805c3821e05a36a17d7af1
Parents : ea87964
Author : Jan-Henrik Bruhn <jan-henrik.bruhn@offis.de>
Date : 2025-12-25T21:06:48+01:00
feature: Add pattern rotation with Konva Transformer
Implement comprehensive pattern rotation functionality:
- Use Konva Transformer for native rotation UI with visual handles
- Apply rotation transformation at upload time to stitch coordinates
- Two-layer preview system: original (draggable/rotatable) and uploaded (locked)
- Automatic position compensation for center shifts after rotation
- PEN encoding/decoding with proper bounds calculation from decoded stitches
- Comprehensive unit tests for rotation math and PEN round-trip
- Restore original unrotated pattern on delete
The rotation is applied by transforming stitch coordinates around the pattern's
geometric center, then re-encoding to PEN format. Position adjustments compensate
for center shifts caused by PEN encoder rounding to maintain visual alignment.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Changes
7 files changed, 1197 insertions(+), 212 deletions(-)
Diff
diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx
index f4ec242..3ad24c4 100644
--- a/src/components/FileUpload.tsx
+++ b/src/components/FileUpload.tsx
@@ -11,6 +11,12 @@ import {
canUploadPattern,
getMachineStateCategory,
} from "../utils/machineStateHelpers";
+import {
+ transformStitchesRotation,
+ calculateRotatedBounds,
+} from "../utils/rotationUtils";
+import { encodeStitchesToPen } from "../formats/pen/encoder";
+import { decodePenData } from "../formats/pen/decoder";
import { PatternInfoSkeleton } from "./SkeletonLoader";
import { PatternInfo } from "./PatternInfo";
import {
@@ -57,13 +63,17 @@ export function FileUpload() {
pesData: pesDataProp,
currentFileName,
patternOffset,
+ patternRotation,
setPattern,
+ setUploadedPattern,
} = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
currentFileName: state.currentFileName,
patternOffset: state.patternOffset,
+ patternRotation: state.patternRotation,
setPattern: state.setPattern,
+ setUploadedPattern: state.setUploadedPattern,
})),
);
@@ -137,26 +147,169 @@ export function FileUpload() {
[fileService, setPattern, pyodideReady, initializePyodide],
);
- const handleUpload = useCallback(() => {
+ const handleUpload = useCallback(async () => {
if (pesData && displayFileName) {
- uploadPattern(pesData.penData, pesData, displayFileName, patternOffset);
+ let penDataToUpload = pesData.penData;
+ let pesDataForUpload = pesData;
+
+ // Apply rotation if needed
+ if (patternRotation && patternRotation !== 0) {
+ console.log(
+ "[FileUpload] Applying rotation before upload:",
+ patternRotation,
+ );
+
+ // Transform stitches
+ const rotatedStitches = transformStitchesRotation(
+ pesData.stitches,
+ patternRotation,
+ pesData.bounds,
+ );
+
+ // Encode to PEN (this will round coordinates)
+ const penResult = encodeStitchesToPen(rotatedStitches);
+ penDataToUpload = new Uint8Array(penResult.penBytes);
+
+ // Decode back to get the ACTUAL pattern (after PEN rounding)
+ const decoded = decodePenData(penDataToUpload);
+
+ // Calculate bounds from the DECODED stitches (the actual data that will be rendered)
+ let decodedMinX = Infinity,
+ decodedMaxX = -Infinity;
+ let decodedMinY = Infinity,
+ decodedMaxY = -Infinity;
+ for (const stitch of decoded.stitches) {
+ if (stitch.x < decodedMinX) decodedMinX = stitch.x;
+ if (stitch.x > decodedMaxX) decodedMaxX = stitch.x;
+ if (stitch.y < decodedMinY) decodedMinY = stitch.y;
+ if (stitch.y > decodedMaxY) decodedMaxY = stitch.y;
+ }
+ const rotatedBounds = {
+ minX: decodedMinX,
+ maxX: decodedMaxX,
+ minY: decodedMinY,
+ maxY: decodedMaxY,
+ };
+
+ // Calculate the center of the rotated pattern
+ const originalCenterX = (pesData.bounds.minX + pesData.bounds.maxX) / 2;
+ const originalCenterY = (pesData.bounds.minY + pesData.bounds.maxY) / 2;
+ const rotatedCenterX = (rotatedBounds.minX + rotatedBounds.maxX) / 2;
+ const rotatedCenterY = (rotatedBounds.minY + rotatedBounds.maxY) / 2;
+ const centerShiftX = rotatedCenterX - originalCenterX;
+ const centerShiftY = rotatedCenterY - originalCenterY;
+
+ console.log("[FileUpload] Pattern centers:", {
+ originalCenter: { x: originalCenterX, y: originalCenterY },
+ rotatedCenter: { x: rotatedCenterX, y: rotatedCenterY },
+ centerShift: { x: centerShiftX, y: centerShiftY },
+ });
+
+ // CRITICAL: Adjust position to compensate for the center shift!
+ // In Konva, visual position = (x - offsetX, y - offsetY).
+ // Original visual pos: (x - originalCenterX, y - originalCenterY)
+ // New visual pos: (newX - rotatedCenterX, newY - rotatedCenterY)
+ // For same visual position: newX = x + (rotatedCenterX - originalCenterX)
+ // So we need to add (rotatedCenter - originalCenter) to the position.
+ const adjustedOffset = {
+ x: patternOffset.x + centerShiftX,
+ y: patternOffset.y + centerShiftY,
+ };
+
+ console.log(
+ "[FileUpload] Adjusting position to compensate for center shift:",
+ {
+ originalPosition: patternOffset,
+ adjustedPosition: adjustedOffset,
+ shift: { x: centerShiftX, y: centerShiftY },
+ },
+ );
+
+ // Create rotated PesPatternData for upload
+ pesDataForUpload = {
+ ...pesData,
+ stitches: rotatedStitches,
+ penData: penDataToUpload,
+ penStitches: decoded,
+ bounds: rotatedBounds,
+ };
+
+ // Save uploaded pattern to store for preview BEFORE starting upload
+ // This allows the preview to show immediately when isUploading becomes true
+ console.log("[FileUpload] Saving uploaded pattern for preview");
+ setUploadedPattern(pesDataForUpload, adjustedOffset);
+
+ // Upload the pattern with offset
+ uploadPattern(
+ penDataToUpload,
+ pesDataForUpload,
+ displayFileName,
+ adjustedOffset,
+ );
+
+ return; // Early return to skip the upload below
+ }
+
+ // Save uploaded pattern to store BEFORE starting upload
+ // (same as original since no rotation)
+ setUploadedPattern(pesDataForUpload, patternOffset);
+
+ // Upload the pattern (no rotation case)
+ uploadPattern(
+ penDataToUpload,
+ pesDataForUpload,
+ displayFileName,
+ patternOffset,
+ );
}
- }, [pesData, displayFileName, uploadPattern, patternOffset]);
+ }, [
+ pesData,
+ displayFileName,
+ uploadPattern,
+ patternOffset,
+ patternRotation,
+ setUploadedPattern,
+ ]);
- // Check if pattern (with offset) fits within hoop bounds
+ // Check if pattern (with offset and rotation) fits within hoop bounds
const checkPatternFitsInHoop = useCallback(() => {
if (!pesData || !machineInfo) {
return { fits: true, error: null };
}
- const { bounds } = pesData;
+ // Calculate rotated bounds if rotation is applied
+ let bounds = pesData.bounds;
+ if (patternRotation && patternRotation !== 0) {
+ bounds = calculateRotatedBounds(pesData.bounds, patternRotation);
+ }
+
const { maxWidth, maxHeight } = machineInfo;
- // Calculate pattern bounds with offset applied
- const patternMinX = bounds.minX + patternOffset.x;
- const patternMaxX = bounds.maxX + patternOffset.x;
- const patternMinY = bounds.minY + patternOffset.y;
- const patternMaxY = bounds.maxY + patternOffset.y;
+ // The patternOffset represents the pattern's CENTER position (due to offsetX/offsetY in canvas)
+ // So we need to calculate bounds relative to the center
+ const centerX = (bounds.minX + bounds.maxX) / 2;
+ const centerY = (bounds.minY + bounds.maxY) / 2;
+
+ // Calculate actual bounds in world coordinates
+ const patternMinX = patternOffset.x - centerX + bounds.minX;
+ const patternMaxX = patternOffset.x - centerX + bounds.maxX;
+ const patternMinY = patternOffset.y - centerY + bounds.minY;
+ const patternMaxY = patternOffset.y - centerY + bounds.maxY;
+
+ console.log("[Bounds Check] Pattern center:", { centerX, centerY });
+ console.log("[Bounds Check] Offset (center position):", patternOffset);
+ console.log("[Bounds Check] Pattern bounds with offset:", {
+ minX: patternMinX,
+ maxX: patternMaxX,
+ minY: patternMinY,
+ maxY: patternMaxY,
+ });
+ console.log("[Bounds Check] Hoop bounds:", {
+ minX: -maxWidth / 2,
+ maxX: maxWidth / 2,
+ minY: -maxHeight / 2,
+ maxY: maxHeight / 2,
+ });
// Hoop bounds (centered at origin)
const hoopMinX = -maxWidth / 2;
@@ -196,7 +349,7 @@ export function FileUpload() {
}
return { fits: true, error: null };
- }, [pesData, machineInfo, patternOffset]);
+ }, [pesData, machineInfo, patternOffset, patternRotation]);
const boundsCheck = checkPatternFitsInHoop();
diff --git a/src/components/KonvaComponents.tsx b/src/components/KonvaComponents.tsx
index a8e15cb..a486628 100644
--- a/src/components/KonvaComponents.tsx
+++ b/src/components/KonvaComponents.tsx
@@ -1,5 +1,6 @@
-import { memo, useMemo } from "react";
+import { memo, useMemo, useState, useCallback } from "react";
import { Group, Line, Rect, Text, Circle } from "react-konva";
+import type { KonvaEventObject } from "konva/lib/Node";
import type { PesPatternData } from "../formats/import/pesImporter";
import { getThreadColor } from "../formats/import/pesImporter";
import type { MachineInfo } from "../types/machine";
@@ -293,3 +294,147 @@ export const CurrentPosition = memo(
);
CurrentPosition.displayName = "CurrentPosition";
+
+interface RotationHandleProps {
+ bounds: { minX: number; maxX: number; minY: number; maxY: number };
+ rotation: number;
+ onRotationChange: (angle: number) => void;
+ onRotationEnd: (angle: number) => void;
+ disabled?: boolean;
+}
+
+export const RotationHandle = memo(
+ ({
+ bounds,
+ rotation,
+ onRotationChange,
+ onRotationEnd,
+ disabled,
+ }: RotationHandleProps) => {
+ const [isDragging, setIsDragging] = useState(false);
+ const [startAngle, setStartAngle] = useState(0);
+
+ const centerX = (bounds.minX + bounds.maxX) / 2;
+ const centerY = (bounds.minY + bounds.maxY) / 2;
+
+ // Calculate handle position based on rotation angle
+ // Start position is top-right corner (maxX, minY), which corresponds to -45° in standard coords
+ const radius = Math.sqrt(
+ Math.pow(bounds.maxX - centerX, 2) + Math.pow(bounds.minY - centerY, 2),
+ );
+ const baseAngle = Math.atan2(bounds.minY - centerY, bounds.maxX - centerX);
+ const currentAngleRad = baseAngle + (rotation * Math.PI) / 180;
+ const handleX = centerX + radius * Math.cos(currentAngleRad);
+ const handleY = centerY + radius * Math.sin(currentAngleRad);
+
+ const handleMouseDown = useCallback(
+ (e: KonvaEventObject<MouseEvent>) => {
+ if (disabled) return;
+ setIsDragging(true);
+
+ const stage = e.target.getStage();
+ if (!stage) return;
+ const pos = stage.getPointerPosition();
+ if (!pos) return;
+ const angle =
+ (Math.atan2(pos.y - centerY, pos.x - centerX) * 180) / Math.PI;
+ setStartAngle(angle - rotation);
+ },
+ [disabled, centerX, centerY, rotation],
+ );
+
+ const handleMouseMove = useCallback(
+ (e: KonvaEventObject<MouseEvent>) => {
+ if (disabled || !isDragging) return;
+
+ const stage = e.target.getStage();
+ if (!stage) return;
+ const pos = stage.getPointerPosition();
+ if (!pos) return;
+ let angle =
+ (Math.atan2(pos.y - centerY, pos.x - centerX) * 180) / Math.PI;
+ angle = angle - startAngle;
+
+ // Snap to 15° if Shift key held
+ if (e.evt.shiftKey) {
+ angle = Math.round(angle / 15) * 15;
+ }
+
+ const normalized = ((angle % 360) + 360) % 360;
+ onRotationChange(normalized);
+ },
+ [disabled, isDragging, centerX, centerY, startAngle, onRotationChange],
+ );
+
+ const handleMouseUp = useCallback(
+ (e: KonvaEventObject<MouseEvent>) => {
+ if (disabled || !isDragging) return;
+ setIsDragging(false);
+
+ const stage = e.target.getStage();
+ if (!stage) return;
+ const pos = stage.getPointerPosition();
+ if (!pos) return;
+ let angle =
+ (Math.atan2(pos.y - centerY, pos.x - centerX) * 180) / Math.PI;
+ angle = angle - startAngle;
+
+ const normalized = ((angle % 360) + 360) % 360;
+ onRotationEnd(normalized);
+ },
+ [disabled, isDragging, centerX, centerY, startAngle, onRotationEnd],
+ );
+
+ if (disabled) return null;
+
+ return (
+ <Group
+ name="rotationHandle"
+ onMouseMove={handleMouseMove}
+ onMouseUp={handleMouseUp}
+ >
+ {/* Line from center to handle */}
+ <Line
+ points={[centerX, centerY, handleX, handleY]}
+ stroke="#FF6B6B"
+ strokeWidth={1}
+ dash={[5, 5]}
+ opacity={0.5}
+ />
+
+ {/* Handle circle */}
+ <Circle
+ x={handleX}
+ y={handleY}
+ radius={10}
+ fill="#FF6B6B"
+ stroke="white"
+ strokeWidth={2}
+ onMouseDown={handleMouseDown}
+ onMouseEnter={(e) => {
+ const container = e.target.getStage()?.container();
+ if (container) container.style.cursor = "grab";
+ }}
+ onMouseLeave={(e) => {
+ const container = e.target.getStage()?.container();
+ if (container) container.style.cursor = "default";
+ }}
+ />
+
+ {/* Angle text */}
+ {isDragging && (
+ <Text
+ x={handleX + 15}
+ y={handleY - 20}
+ text={`${rotation.toFixed(0)}°`}
+ fontSize={12}
+ fill="black"
+ padding={4}
+ />
+ )}
+ </Group>
+ );
+ },
+);
+
+RotationHandle.displayName = "RotationHandle";
diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx
index 098c022..5ad18e4 100644
--- a/src/components/PatternCanvas.tsx
+++ b/src/components/PatternCanvas.tsx
@@ -2,8 +2,9 @@ import { useEffect, useRef, useState, useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
import { usePatternStore } from "../stores/usePatternStore";
-import { Stage, Layer, Group } from "react-konva";
+import { Stage, Layer, Group, Transformer } from "react-konva";
import Konva from "konva";
+import type { KonvaEventObject } from "konva/lib/Node";
import {
PlusIcon,
MinusIcon,
@@ -45,12 +46,20 @@ export function PatternCanvas() {
const {
pesData,
patternOffset: initialPatternOffset,
+ patternRotation: initialPatternRotation,
+ uploadedPesData,
+ uploadedPatternOffset: initialUploadedPatternOffset,
setPatternOffset,
+ setPatternRotation,
} = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
patternOffset: state.patternOffset,
+ patternRotation: state.patternRotation,
+ uploadedPesData: state.uploadedPesData,
+ uploadedPatternOffset: state.uploadedPatternOffset,
setPatternOffset: state.setPatternOffset,
+ setPatternRotation: state.setPatternRotation,
})),
);
@@ -58,12 +67,17 @@ export function PatternCanvas() {
const patternUploaded = usePatternUploaded();
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null);
+ const patternGroupRef = useRef<Konva.Group | null>(null);
+ const transformerRef = useRef<Konva.Transformer | null>(null);
const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
const [stageScale, setStageScale] = useState(1);
const [localPatternOffset, setLocalPatternOffset] = useState(
initialPatternOffset || { x: 0, y: 0 },
);
+ const [localPatternRotation, setLocalPatternRotation] = useState(
+ initialPatternRotation || 0,
+ );
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const initialScaleRef = useRef<number>(1);
const prevPesDataRef = useRef<PesPatternData | null>(null);
@@ -81,6 +95,14 @@ export function PatternCanvas() {
);
}
+ // Update pattern rotation when initialPatternRotation changes
+ if (
+ initialPatternRotation !== undefined &&
+ localPatternRotation !== initialPatternRotation
+ ) {
+ setLocalPatternRotation(initialPatternRotation);
+ }
+
// Track container size
useEffect(() => {
if (!containerRef.current) return;
@@ -105,16 +127,18 @@ export function PatternCanvas() {
// Calculate and store initial scale when pattern or hoop changes
useEffect(() => {
- if (!pesData || containerSize.width === 0) {
+ // Use whichever pattern is available (uploaded or original)
+ const currentPattern = uploadedPesData || pesData;
+ if (!currentPattern || containerSize.width === 0) {
prevPesDataRef.current = null;
return;
}
// Only recalculate if pattern changed
- if (prevPesDataRef.current !== pesData) {
- prevPesDataRef.current = pesData;
+ if (prevPesDataRef.current !== currentPattern) {
+ prevPesDataRef.current = currentPattern;
- const { bounds } = pesData;
+ const { bounds } = currentPattern;
const viewWidth = machineInfo
? machineInfo.maxWidth
: bounds.maxX - bounds.minX;
@@ -135,7 +159,7 @@ export function PatternCanvas() {
setStageScale(initialScale);
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}
- }, [pesData, machineInfo, containerSize]);
+ }, [pesData, uploadedPesData, machineInfo, containerSize]);
// Wheel zoom handler
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
@@ -252,10 +276,79 @@ export function PatternCanvas() {
[setPatternOffset],
);
- const borderColor = pesData
+ // Attach/detach transformer based on state
+ const attachTransformer = useCallback(() => {
+ if (!transformerRef.current || !patternGroupRef.current) {
+ console.log(
+ "[PatternCanvas] Cannot attach transformer - refs not ready",
+ {
+ hasTransformer: !!transformerRef.current,
+ hasPatternGroup: !!patternGroupRef.current,
+ },
+ );
+ return;
+ }
+
+ if (!patternUploaded && !isUploading) {
+ console.log("[PatternCanvas] Attaching transformer");
+ transformerRef.current.nodes([patternGroupRef.current]);
+ transformerRef.current.getLayer()?.batchDraw();
+ } else {
+ console.log("[PatternCanvas] Detaching transformer");
+ transformerRef.current.nodes([]);
+ }
+ }, [patternUploaded, isUploading]);
+
+ // Call attachTransformer when conditions change
+ useEffect(() => {
+ attachTransformer();
+ }, [attachTransformer, pesData]);
+
+ // Sync node rotation with state (important for when rotation is reset to 0 after upload)
+ useEffect(() => {
+ if (patternGroupRef.current) {
+ patternGroupRef.current.rotation(localPatternRotation);
+ }
+ }, [localPatternRotation]);
+
+ // Handle transformer rotation - just store the angle, apply at upload time
+ const handleTransformEnd = useCallback(
+ (e: KonvaEventObject<Event>) => {
+ if (!pesData) return;
+
+ const node = e.target;
+ // Read rotation from the node
+ const totalRotation = node.rotation();
+ const normalizedRotation = ((totalRotation % 360) + 360) % 360;
+
+ setLocalPatternRotation(normalizedRotation);
+
+ // Also read position in case the Transformer affected it
+ const newOffset = {
+ x: node.x(),
+ y: node.y(),
+ };
+ setLocalPatternOffset(newOffset);
+
+ // Store rotation angle and position
+ setPatternRotation(normalizedRotation);
+ setPatternOffset(newOffset.x, newOffset.y);
+
+ console.log(
+ "[Canvas] Transform end - rotation:",
+ normalizedRotation,
+ "degrees, position:",
+ newOffset,
+ );
+ },
+ [setPatternRotation, setPatternOffset, pesData],
+ );
+
+ const hasPattern = pesData || uploadedPesData;
+ const borderColor = hasPattern
? "border-tertiary-600 dark:border-tertiary-500"
: "border-gray-400 dark:border-gray-600";
- const iconColor = pesData
+ const iconColor = hasPattern
? "text-tertiary-600 dark:text-tertiary-400"
: "text-gray-600 dark:text-gray-400";
@@ -268,12 +361,27 @@ export function PatternCanvas() {
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Pattern Preview</CardTitle>
- {pesData ? (
+ {hasPattern ? (
<CardDescription className="text-xs">
- {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)}{" "}
- ×{" "}
- {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)}{" "}
- mm
+ {(() => {
+ const displayPattern = uploadedPesData || pesData;
+ return displayPattern ? (
+ <>
+ {(
+ (displayPattern.bounds.maxX -
+ displayPattern.bounds.minX) /
+ 10
+ ).toFixed(1)}{" "}
+ ×{" "}
+ {(
+ (displayPattern.bounds.maxY -
+ displayPattern.bounds.minY) /
+ 10
+ ).toFixed(1)}{" "}
+ mm
+ </>
+ ) : null;
+ })()}
</CardDescription>
) : (
<CardDescription className="text-xs">
@@ -317,11 +425,11 @@ export function PatternCanvas() {
>
{/* Background layer: grid, origin, hoop */}
<Layer>
- {pesData && (
+ {hasPattern && (
<>
<Grid
gridSize={100}
- bounds={pesData.bounds}
+ bounds={(uploadedPesData || pesData)!.bounds}
machineInfo={machineInfo}
/>
<Origin />
@@ -330,61 +438,166 @@ export function PatternCanvas() {
)}
</Layer>
- {/* Pattern layer: draggable stitches and bounds */}
- <Layer>
- {pesData && (
- <Group
- name="pattern-group"
- draggable={!patternUploaded && !isUploading}
- x={localPatternOffset.x}
- y={localPatternOffset.y}
- onDragEnd={handlePatternDragEnd}
- onMouseEnter={(e) => {
- const stage = e.target.getStage();
- if (stage && !patternUploaded && !isUploading)
- stage.container().style.cursor = "move";
- }}
- onMouseLeave={(e) => {
- const stage = e.target.getStage();
- if (stage && !patternUploaded && !isUploading)
- stage.container().style.cursor = "grab";
- }}
- >
- <Stitches
- stitches={pesData.penStitches.stitches.map(
- (s, i): [number, number, number, number] => {
- // Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
- const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
- const colorIndex =
- pesData.penStitches.colorBlocks.find(
- (b) => i >= b.startStitch && i <= b.endStitch,
- )?.colorIndex ?? 0;
- return [s.x, s.y, cmd, colorIndex];
- },
- )}
- pesData={pesData}
- currentStitchIndex={sewingProgress?.currentStitch || 0}
- showProgress={patternUploaded || isUploading}
- />
- <PatternBounds bounds={pesData.bounds} />
- </Group>
- )}
+ {/* Original pattern layer: draggable with transformer (shown before upload starts) */}
+ <Layer visible={!isUploading && !patternUploaded}>
+ {pesData &&
+ (() => {
+ const originalCenterX =
+ (pesData.bounds.minX + pesData.bounds.maxX) / 2;
+ const originalCenterY =
+ (pesData.bounds.minY + pesData.bounds.maxY) / 2;
+ console.log("[Canvas] Rendering original pattern:", {
+ position: localPatternOffset,
+ rotation: localPatternRotation,
+ center: { x: originalCenterX, y: originalCenterY },
+ bounds: pesData.bounds,
+ });
+ return (
+ <>
+ <Group
+ name="pattern-group"
+ ref={(node) => {
+ patternGroupRef.current = node;
+ // Set initial rotation from state
+ if (node) {
+ node.rotation(localPatternRotation);
+ // Try to attach transformer when group is mounted
+ attachTransformer();
+ }
+ }}
+ draggable={!isUploading}
+ x={localPatternOffset.x}
+ y={localPatternOffset.y}
+ offsetX={originalCenterX}
+ offsetY={originalCenterY}
+ onDragEnd={handlePatternDragEnd}
+ onTransformEnd={handleTransformEnd}
+ onMouseEnter={(e) => {
+ const stage = e.target.getStage();
+ if (stage && !isUploading)
+ stage.container().style.cursor = "move";
+ }}
+ onMouseLeave={(e) => {
+ const stage = e.target.getStage();
+ if (stage && !isUploading)
+ stage.container().style.cursor = "grab";
+ }}
+ >
+ <Stitches
+ stitches={pesData.penStitches.stitches.map(
+ (s, i): [number, number, number, number] => {
+ // Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
+ const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
+ const colorIndex =
+ pesData.penStitches.colorBlocks.find(
+ (b) =>
+ i >= b.startStitch && i <= b.endStitch,
+ )?.colorIndex ?? 0;
+ return [s.x, s.y, cmd, colorIndex];
+ },
+ )}
+ pesData={pesData}
+ currentStitchIndex={0}
+ showProgress={false}
+ />
+ <PatternBounds bounds={pesData.bounds} />
+ </Group>
+ <Transformer
+ ref={(node) => {
+ transformerRef.current = node;
+ // Try to attach transformer when transformer is mounted
+ if (node) {
+ attachTransformer();
+ }
+ }}
+ enabledAnchors={[]}
+ rotateEnabled={true}
+ borderEnabled={true}
+ borderStroke="#FF6B6B"
+ borderStrokeWidth={2}
+ rotationSnaps={[0, 45, 90, 135, 180, 225, 270, 315]}
+ ignoreStroke={true}
+ rotateAnchorOffset={20}
+ />
+ </>
+ );
+ })()}
</Layer>
- {/* Current position layer */}
- <Layer>
- {pesData &&
- pesData.penStitches &&
+ {/* Uploaded pattern layer: locked, rotation baked in (shown during and after upload) */}
+ <Layer visible={isUploading || patternUploaded}>
+ {uploadedPesData &&
+ (() => {
+ const uploadedCenterX =
+ (uploadedPesData.bounds.minX +
+ uploadedPesData.bounds.maxX) /
+ 2;
+ const uploadedCenterY =
+ (uploadedPesData.bounds.minY +
+ uploadedPesData.bounds.maxY) /
+ 2;
+ console.log("[Canvas] Rendering uploaded pattern:", {
+ position: initialUploadedPatternOffset,
+ center: { x: uploadedCenterX, y: uploadedCenterY },
+ bounds: uploadedPesData.bounds,
+ });
+ return (
+ <Group
+ name="uploaded-pattern-group"
+ x={initialUploadedPatternOffset.x}
+ y={initialUploadedPatternOffset.y}
+ offsetX={uploadedCenterX}
+ offsetY={uploadedCenterY}
+ >
+ <Stitches
+ stitches={uploadedPesData.penStitches.stitches.map(
+ (s, i): [number, number, number, number] => {
+ const cmd = s.isJump ? 0x10 : 0;
+ const colorIndex =
+ uploadedPesData.penStitches.colorBlocks.find(
+ (b) => i >= b.startStitch && i <= b.endStitch,
+ )?.colorIndex ?? 0;
+ return [s.x, s.y, cmd, colorIndex];
+ },
+ )}
+ pesData={uploadedPesData}
+ currentStitchIndex={
+ sewingProgress?.currentStitch || 0
+ }
+ showProgress={true}
+ />
+ <PatternBounds bounds={uploadedPesData.bounds} />
+ </Group>
+ );
+ })()}
+ </Layer>
+
+ {/* Current position layer (for uploaded pattern during sewing) */}
+ <Layer visible={isUploading || patternUploaded}>
+ {uploadedPesData &&
sewingProgress &&
sewingProgress.currentStitch > 0 && (
- <Group x={localPatternOffset.x} y={localPatternOffset.y}>
+ <Group
+ x={initialUploadedPatternOffset.x}
+ y={initialUploadedPatternOffset.y}
+ offsetX={
+ (uploadedPesData.bounds.minX +
+ uploadedPesData.bounds.maxX) /
+ 2
+ }
+ offsetY={
+ (uploadedPesData.bounds.minY +
+ uploadedPesData.bounds.maxY) /
+ 2
+ }
+ >
<CurrentPosition
currentStitchIndex={sewingProgress.currentStitch}
- stitches={pesData.penStitches.stitches.map(
+ stitches={uploadedPesData.penStitches.stitches.map(
(s, i): [number, number, number, number] => {
const cmd = s.isJump ? 0x10 : 0;
const colorIndex =
- pesData.penStitches.colorBlocks.find(
+ uploadedPesData.penStitches.colorBlocks.find(
(b) => i >= b.startStitch && i <= b.endStitch,
)?.colorIndex ?? 0;
return [s.x, s.y, cmd, colorIndex];
@@ -398,143 +611,173 @@ export function PatternCanvas() {
)}
{/* Placeholder overlay when no pattern is loaded */}
- {!pesData && (
+ {!hasPattern && (
<div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
Load a PES file to preview the pattern
</div>
)}
{/* Pattern info overlays */}
- {pesData && (
- <>
- {/* Thread Legend Overlay */}
- <div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
- <h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
- Colors
- </h4>
- {pesData.uniqueColors.map((color, idx) => {
- // Primary metadata: brand and catalog number
- const primaryMetadata = [
- color.brand,
- color.catalogNumber ? `#${color.catalogNumber}` : null,
- ]
- .filter(Boolean)
- .join(" ");
-
- // Secondary metadata: chart and description
- // Only show chart if it's different from catalogNumber
- const secondaryMetadata = [
- color.chart && color.chart !== color.catalogNumber
- ? color.chart
- : null,
- color.description,
- ]
- .filter(Boolean)
- .join(" ");
-
- return (
+ {hasPattern &&
+ (() => {
+ const displayPattern = uploadedPesData || pesData;
+ return (
+ displayPattern && (
+ <>
+ {/* Thread Legend Overlay */}
+ <div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
+ <h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
+ Colors
+ </h4>
+ {displayPattern.uniqueColors.map((color, idx) => {
+ // Primary metadata: brand and catalog number
+ const primaryMetadata = [
+ color.brand,
+ color.catalogNumber
+ ? `#${color.catalogNumber}`
+ : null,
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ // Secondary metadata: chart and description
+ // Only show chart if it's different from catalogNumber
+ const secondaryMetadata = [
+ color.chart && color.chart !== color.catalogNumber
+ ? color.chart
+ : null,
+ color.description,
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ return (
+ <div
+ key={idx}
+ className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
+ >
+ <div
+ className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
+ style={{ backgroundColor: color.hex }}
+ />
+ <div className="flex-1 min-w-0">
+ <div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
+ Color {idx + 1}
+ </div>
+ {(primaryMetadata || secondaryMetadata) && (
+ <div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
+ {primaryMetadata}
+ {primaryMetadata && secondaryMetadata && (
+ <span className="mx-1">•</span>
+ )}
+ {secondaryMetadata}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+
+ {/* Pattern Offset Indicator */}
<div
- key={idx}
- className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
+ className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
+ isUploading || patternUploaded
+ ? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
+ : "bg-white/95 dark:bg-gray-800/95"
+ }`}
>
- <div
- className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
- style={{ backgroundColor: color.hex }}
- />
- <div className="flex-1 min-w-0">
- <div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
- Color {idx + 1}
+ <div className="flex items-center justify-between mb-1">
+ <div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
+ Pattern Position:
</div>
- {(primaryMetadata || secondaryMetadata) && (
- <div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
- {primaryMetadata}
- {primaryMetadata && secondaryMetadata && (
- <span className="mx-1">•</span>
- )}
- {secondaryMetadata}
+ {(isUploading || patternUploaded) && (
+ <div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
+ <LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
+ <span className="text-xs font-bold">
+ {isUploading ? "UPLOADING" : "LOCKED"}
+ </span>
</div>
)}
</div>
+ <div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
+ {isUploading || patternUploaded ? (
+ <>
+ X:{" "}
+ {(initialUploadedPatternOffset.x / 10).toFixed(1)}
+ mm, Y:{" "}
+ {(initialUploadedPatternOffset.y / 10).toFixed(1)}mm
+ </>
+ ) : (
+ <>
+ X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "}
+ {(localPatternOffset.y / 10).toFixed(1)}mm
+ </>
+ )}
+ </div>
+ {!isUploading &&
+ !patternUploaded &&
+ localPatternRotation !== 0 && (
+ <div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
+ Rotation: {localPatternRotation.toFixed(1)}°
+ </div>
+ )}
+ <div className="text-xs text-gray-600 dark:text-gray-400 italic">
+ {isUploading
+ ? "Uploading pattern..."
+ : patternUploaded
+ ? "Pattern locked • Drag background to pan"
+ : "Drag pattern to move • Drag background to pan"}
+ </div>
</div>
- );
- })}
- </div>
-
- {/* Pattern Offset Indicator */}
- <div
- className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
- patternUploaded
- ? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
- : "bg-white/95 dark:bg-gray-800/95"
- }`}
- >
- <div className="flex items-center justify-between mb-1">
- <div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
- Pattern Position:
- </div>
- {patternUploaded && (
- <div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
- <LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
- <span className="text-xs font-bold">LOCKED</span>
+
+ {/* Zoom Controls Overlay */}
+ <div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
+ <Button
+ variant="outline"
+ size="icon"
+ className="w-7 h-7 sm:w-8 sm:h-8"
+ onClick={handleCenterPattern}
+ disabled={!pesData || patternUploaded || isUploading}
+ title="Center Pattern in Hoop"
+ >
+ <ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5" />
+ </Button>
+ <Button
+ variant="outline"
+ size="icon"
+ className="w-7 h-7 sm:w-8 sm:h-8"
+ onClick={handleZoomIn}
+ title="Zoom In"
+ >
+ <PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
+ </Button>
+ <span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
+ {Math.round(stageScale * 100)}%
+ </span>
+ <Button
+ variant="outline"
+ size="icon"
+ className="w-7 h-7 sm:w-8 sm:h-8"
+ onClick={handleZoomOut}
+ title="Zoom Out"
+ >
+ <MinusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
+ </Button>
+ <Button
+ variant="outline"
+ size="icon"
+ className="w-7 h-7 sm:w-8 sm:h-8 ml-1"
+ onClick={handleZoomReset}
+ title="Reset Zoom"
+ >
+ <ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
+ </Button>
</div>
- )}
- </div>
- <div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
- X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "}
- {(localPatternOffset.y / 10).toFixed(1)}mm
- </div>
- <div className="text-xs text-gray-600 dark:text-gray-400 italic">
- {patternUploaded
- ? "Pattern locked • Drag background to pan"
- : "Drag pattern to move • Drag background to pan"}
- </div>
- </div>
-
- {/* Zoom Controls Overlay */}
- <div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
- <Button
- variant="outline"
- size="icon"
- className="w-7 h-7 sm:w-8 sm:h-8"
- onClick={handleCenterPattern}
- disabled={!pesData || patternUploaded || isUploading}
- title="Center Pattern in Hoop"
- >
- <ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5" />
- </Button>
- <Button
- variant="outline"
- size="icon"
- className="w-7 h-7 sm:w-8 sm:h-8"
- onClick={handleZoomIn}
- title="Zoom In"
- >
- <PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
- </Button>
- <span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
- {Math.round(stageScale * 100)}%
- </span>
- <Button
- variant="outline"
- size="icon"
- className="w-7 h-7 sm:w-8 sm:h-8"
- onClick={handleZoomOut}
- title="Zoom Out"
- >
- <MinusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
- </Button>
- <Button
- variant="outline"
- size="icon"
- className="w-7 h-7 sm:w-8 sm:h-8 ml-1"
- onClick={handleZoomReset}
- title="Reset Zoom"
- >
- <ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
- </Button>
- </div>
- </>
- )}
+ </>
+ )
+ );
+ })()}
</div>
</CardContent>
</Card>
diff --git a/src/stores/useMachineStore.ts b/src/stores/useMachineStore.ts
index b33a3ee..0ecef31 100644
--- a/src/stores/useMachineStore.ts
+++ b/src/stores/useMachineStore.ts
@@ -14,6 +14,7 @@ import { uuidToString } from "../services/PatternCacheService";
import { createStorageService } from "../platform";
import type { IStorageService } from "../platform/interfaces/IStorageService";
import type { PesPatternData } from "../formats/import/pesImporter";
+import { usePatternStore } from "./usePatternStore";
interface MachineState {
// Service instances
@@ -441,6 +442,9 @@ export const useMachineStore = create<MachineState>((set, get) => ({
resumeFileName: null,
});
+ // Clear uploaded pattern data in pattern store
+ usePatternStore.getState().clearUploadedPattern();
+
await refreshStatus();
} catch (err) {
set({
diff --git a/src/stores/usePatternStore.ts b/src/stores/usePatternStore.ts
index 1c20dc2..3e1a850 100644
--- a/src/stores/usePatternStore.ts
+++ b/src/stores/usePatternStore.ts
@@ -2,68 +2,112 @@ import { create } from "zustand";
import type { PesPatternData } from "../formats/import/pesImporter";
interface PatternState {
- // Pattern data
+ // Original pattern (pre-upload)
pesData: PesPatternData | null;
currentFileName: string;
patternOffset: { x: number; y: number };
+ patternRotation: number; // rotation in degrees (0-360)
+
+ // Uploaded pattern (post-upload, rotation baked in)
+ uploadedPesData: PesPatternData | null; // Pattern with rotation applied
+ uploadedPatternOffset: { x: number; y: number }; // Offset with center shift compensation
+
patternUploaded: boolean;
// Actions
setPattern: (data: PesPatternData, fileName: string) => void;
setPatternOffset: (x: number, y: number) => void;
- setPatternUploaded: (uploaded: boolean) => void;
- clearPattern: () => void;
+ setPatternRotation: (rotation: number) => void;
+ setUploadedPattern: (
+ uploadedData: PesPatternData,
+ uploadedOffset: { x: number; y: number },
+ ) => void;
+ clearUploadedPattern: () => void;
resetPatternOffset: () => void;
+ resetRotation: () => void;
}
export const usePatternStore = create<PatternState>((set) => ({
- // Initial state
+ // Initial state - original pattern
pesData: null,
currentFileName: "",
patternOffset: { x: 0, y: 0 },
+ patternRotation: 0,
+
+ // Uploaded pattern
+ uploadedPesData: null,
+ uploadedPatternOffset: { x: 0, y: 0 },
+
patternUploaded: false,
- // Set pattern data and filename
+ // Set pattern data and filename (replaces current pattern)
setPattern: (data: PesPatternData, fileName: string) => {
set({
pesData: data,
currentFileName: fileName,
- patternOffset: { x: 0, y: 0 }, // Reset offset when new pattern is loaded
+ patternOffset: { x: 0, y: 0 },
+ patternRotation: 0,
+ uploadedPesData: null, // Clear uploaded pattern when loading new
+ uploadedPatternOffset: { x: 0, y: 0 },
patternUploaded: false,
});
},
- // Update pattern offset
+ // Update pattern offset (for original pattern only)
setPatternOffset: (x: number, y: number) => {
set({ patternOffset: { x, y } });
console.log("[PatternStore] Pattern offset changed:", { x, y });
},
- // Mark pattern as uploaded/not uploaded
- setPatternUploaded: (uploaded: boolean) => {
- set({ patternUploaded: uploaded });
+ // Set pattern rotation (for original pattern only)
+ setPatternRotation: (rotation: number) => {
+ set({ patternRotation: rotation % 360 });
+ console.log("[PatternStore] Pattern rotation changed:", rotation);
+ },
+
+ // Set uploaded pattern data (called after upload completes)
+ setUploadedPattern: (
+ uploadedData: PesPatternData,
+ uploadedOffset: { x: number; y: number },
+ ) => {
+ set({
+ uploadedPesData: uploadedData,
+ uploadedPatternOffset: uploadedOffset,
+ patternUploaded: true,
+ });
+ console.log("[PatternStore] Uploaded pattern set");
},
- // Clear pattern (but keep data visible for re-editing)
- clearPattern: () => {
+ // Clear uploaded pattern (called when deleting from machine)
+ clearUploadedPattern: () => {
set({
+ uploadedPesData: null,
+ uploadedPatternOffset: { x: 0, y: 0 },
patternUploaded: false,
- // Note: We intentionally DON'T clear pesData or currentFileName
- // so the pattern remains visible in the canvas for re-editing
});
+ console.log("[PatternStore] Uploaded pattern cleared");
},
// Reset pattern offset to default
resetPatternOffset: () => {
set({ patternOffset: { x: 0, y: 0 } });
},
+
+ // Reset pattern rotation to default
+ resetRotation: () => {
+ set({ patternRotation: 0 });
+ },
}));
// Selector hooks for common use cases
export const usePesData = () => usePatternStore((state) => state.pesData);
+export const useUploadedPesData = () =>
+ usePatternStore((state) => state.uploadedPesData);
export const usePatternFileName = () =>
usePatternStore((state) => state.currentFileName);
export const usePatternOffset = () =>
usePatternStore((state) => state.patternOffset);
-export const usePatternUploaded = () =>
- usePatternStore((state) => state.patternUploaded);
+export const useUploadedPatternOffset = () =>
+ usePatternStore((state) => state.uploadedPatternOffset);
+export const usePatternRotation = () =>
+ usePatternStore((state) => state.patternRotation);
diff --git a/src/utils/rotationUtils.test.ts b/src/utils/rotationUtils.test.ts
new file mode 100644
index 0000000..25db9e2
--- /dev/null
+++ b/src/utils/rotationUtils.test.ts
@@ -0,0 +1,314 @@
+import { describe, it, expect } from "vitest";
+import {
+ rotatePoint,
+ transformStitchesRotation,
+ calculateRotatedBounds,
+ normalizeAngle,
+} from "./rotationUtils";
+import { encodeStitchesToPen } from "../formats/pen/encoder";
+import { decodePenData } from "../formats/pen/decoder";
+
+describe("rotationUtils", () => {
+ describe("rotatePoint", () => {
+ it("should rotate 90° correctly", () => {
+ const result = rotatePoint(100, 0, 0, 0, 90);
+ expect(result.x).toBeCloseTo(0, 1);
+ expect(result.y).toBeCloseTo(100, 1);
+ });
+
+ it("should rotate 180° correctly", () => {
+ const result = rotatePoint(100, 50, 0, 0, 180);
+ expect(result.x).toBeCloseTo(-100, 1);
+ expect(result.y).toBeCloseTo(-50, 1);
+ });
+
+ it("should handle 0° rotation (no change)", () => {
+ const result = rotatePoint(100, 50, 0, 0, 0);
+ expect(result.x).toBe(100);
+ expect(result.y).toBe(50);
+ });
+
+ it("should rotate 45° correctly", () => {
+ const result = rotatePoint(100, 0, 0, 0, 45);
+ expect(result.x).toBeCloseTo(70.71, 1);
+ expect(result.y).toBeCloseTo(70.71, 1);
+ });
+
+ it("should rotate around a custom center", () => {
+ const result = rotatePoint(150, 100, 100, 100, 90);
+ expect(result.x).toBeCloseTo(100, 1);
+ expect(result.y).toBeCloseTo(150, 1);
+ });
+ });
+
+ describe("transformStitchesRotation", () => {
+ it("should rotate stitches around pattern center (centered pattern)", () => {
+ const stitches = [
+ [100, 0, 0, 0],
+ [0, 100, 0, 0],
+ [-100, 0, 0, 0],
+ [0, -100, 0, 0],
+ ];
+ const bounds = { minX: -100, maxX: 100, minY: -100, maxY: 100 };
+
+ const rotated = transformStitchesRotation(stitches, 90, bounds);
+
+ expect(rotated[0][0]).toBeCloseTo(0, 0);
+ expect(rotated[0][1]).toBeCloseTo(100, 0);
+ expect(rotated[1][0]).toBeCloseTo(-100, 0);
+ expect(rotated[1][1]).toBeCloseTo(0, 0);
+ });
+
+ it("should preserve command and color data", () => {
+ const stitches = [[100, 50, 0x10, 2]];
+ const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
+
+ const rotated = transformStitchesRotation(stitches, 45, bounds);
+
+ expect(rotated[0][2]).toBe(0x10); // Command unchanged
+ expect(rotated[0][3]).toBe(2); // Color unchanged
+ });
+
+ it("should handle 0° as no-op", () => {
+ const stitches = [[100, 50, 0, 0]];
+ const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
+
+ const rotated = transformStitchesRotation(stitches, 0, bounds);
+
+ expect(rotated).toBe(stitches); // Same reference
+ });
+
+ it("should handle 360° as no-op", () => {
+ const stitches = [[100, 50, 0, 0]];
+ const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
+
+ const rotated = transformStitchesRotation(stitches, 360, bounds);
+
+ expect(rotated).toBe(stitches); // Same reference
+ });
+
+ it("should round coordinates to integers", () => {
+ const stitches = [[100, 0, 0, 0]];
+ const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
+
+ const rotated = transformStitchesRotation(stitches, 45, bounds);
+
+ // Coordinates should be integers
+ expect(Number.isInteger(rotated[0][0])).toBe(true);
+ expect(Number.isInteger(rotated[0][1])).toBe(true);
+ });
+
+ it("should rotate off-center pattern around its own center", () => {
+ // Pattern bounds not centered at origin (like the real-world case)
+ const bounds = { minX: -23, maxX: 751, minY: -369, maxY: 485 };
+ const centerX = (bounds.minX + bounds.maxX) / 2; // 364
+ const centerY = (bounds.minY + bounds.maxY) / 2; // 58
+
+ // Stitch at the pattern's center
+ const stitches = [[centerX, centerY, 0, 0]];
+
+ // Rotate by any angle - center point should stay at center
+ const rotated = transformStitchesRotation(stitches, 90, bounds);
+
+ // Center stitch should remain at center (within rounding)
+ expect(rotated[0][0]).toBeCloseTo(centerX, 0);
+ expect(rotated[0][1]).toBeCloseTo(centerY, 0);
+ });
+
+ it("should rotate off-center pattern corners correctly", () => {
+ // Off-center pattern
+ const bounds = { minX: 100, maxX: 300, minY: 200, maxY: 400 };
+
+ // Test all four corners
+ const stitches = [
+ [100, 200, 0, 0], // top-left
+ [300, 200, 0, 0], // top-right
+ [100, 400, 0, 0], // bottom-left
+ [300, 400, 0, 0], // bottom-right
+ ];
+
+ const rotated = transformStitchesRotation(stitches, 90, bounds);
+
+ // After 90° rotation around center (200, 300):
+ // top-left (100, 200) -> relative (-100, -100) -> rotated (100, -100) -> absolute (300, 200)
+ expect(rotated[0][0]).toBeCloseTo(300, 0);
+ expect(rotated[0][1]).toBeCloseTo(200, 0);
+
+ // top-right (300, 200) -> relative (100, -100) -> rotated (100, 100) -> absolute (300, 400)
+ expect(rotated[1][0]).toBeCloseTo(300, 0);
+ expect(rotated[1][1]).toBeCloseTo(400, 0);
+
+ // bottom-left (100, 400) -> relative (-100, 100) -> rotated (-100, -100) -> absolute (100, 200)
+ expect(rotated[2][0]).toBeCloseTo(100, 0);
+ expect(rotated[2][1]).toBeCloseTo(200, 0);
+
+ // bottom-right (300, 400) -> relative (100, 100) -> rotated (-100, 100) -> absolute (100, 400)
+ expect(rotated[3][0]).toBeCloseTo(100, 0);
+ expect(rotated[3][1]).toBeCloseTo(400, 0);
+ });
+
+ it("should handle real-world off-center pattern (actual user case)", () => {
+ const bounds = { minX: -23, maxX: 751, minY: -369, maxY: 485 };
+ const centerX = 364;
+ const centerY = 58;
+
+ // A stitch at the top-right corner
+ const stitches = [[751, -369, 0, 0]];
+
+ const rotated = transformStitchesRotation(stitches, 45, bounds);
+
+ // Distance from center: sqrt((751-364)^2 + (-369-58)^2) = sqrt(149769 + 182329) = 576.4
+ // This distance should be preserved after rotation
+ const origDist = Math.sqrt(
+ Math.pow(751 - centerX, 2) + Math.pow(-369 - centerY, 2),
+ );
+ const rotDist = Math.sqrt(
+ Math.pow(rotated[0][0] - centerX, 2) +
+ Math.pow(rotated[0][1] - centerY, 2),
+ );
+
+ expect(rotDist).toBeCloseTo(origDist, 0);
+ });
+ });
+
+ describe("calculateRotatedBounds", () => {
+ it("should expand bounds after 45° rotation", () => {
+ const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
+
+ const rotated = calculateRotatedBounds(bounds, 45);
+
+ // After 45° rotation, bounds should expand
+ expect(Math.abs(rotated.minX)).toBeGreaterThan(100);
+ expect(Math.abs(rotated.minY)).toBeGreaterThan(50);
+ });
+
+ it("should maintain bounds for 0° rotation", () => {
+ const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
+
+ const rotated = calculateRotatedBounds(bounds, 0);
+
+ expect(rotated).toEqual(bounds);
+ });
+
+ it("should maintain bounds for 360° rotation", () => {
+ const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
+
+ const rotated = calculateRotatedBounds(bounds, 360);
+
+ expect(rotated).toEqual(bounds);
+ });
+
+ it("should handle 90° rotation symmetrically", () => {
+ const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
+
+ const rotated = calculateRotatedBounds(bounds, 90);
+
+ // X and Y bounds swap
+ expect(rotated.minX).toBeCloseTo(-50, 0);
+ expect(rotated.maxX).toBeCloseTo(50, 0);
+ expect(rotated.minY).toBeCloseTo(-100, 0);
+ expect(rotated.maxY).toBeCloseTo(100, 0);
+ });
+
+ it("should handle asymmetric bounds correctly", () => {
+ const bounds = { minX: 0, maxX: 200, minY: 0, maxY: 100 };
+
+ const rotated = calculateRotatedBounds(bounds, 90);
+
+ const centerX = (bounds.minX + bounds.maxX) / 2;
+ const centerY = (bounds.minY + bounds.maxY) / 2;
+
+ // After 90° rotation around center
+ expect(rotated.minX).toBeCloseTo(centerX - 50, 0);
+ expect(rotated.maxX).toBeCloseTo(centerX + 50, 0);
+ expect(rotated.minY).toBeCloseTo(centerY - 100, 0);
+ expect(rotated.maxY).toBeCloseTo(centerY + 100, 0);
+ });
+ });
+
+ describe("normalizeAngle", () => {
+ it("should normalize negative angles", () => {
+ expect(normalizeAngle(-45)).toBe(315);
+ expect(normalizeAngle(-90)).toBe(270);
+ expect(normalizeAngle(-180)).toBe(180);
+ });
+
+ it("should normalize angles > 360", () => {
+ expect(normalizeAngle(405)).toBe(45);
+ expect(normalizeAngle(720)).toBe(0);
+ expect(normalizeAngle(450)).toBe(90);
+ });
+
+ it("should keep valid angles unchanged", () => {
+ expect(normalizeAngle(0)).toBe(0);
+ expect(normalizeAngle(45)).toBe(45);
+ expect(normalizeAngle(180)).toBe(180);
+ expect(normalizeAngle(359)).toBe(359);
+ });
+
+ it("should handle very large angles", () => {
+ expect(normalizeAngle(1080)).toBe(0);
+ expect(normalizeAngle(1125)).toBe(45);
+ });
+ });
+
+ describe("PEN encode/decode round-trip with rotation", () => {
+ it("should preserve rotated stitches through encode-decode cycle", () => {
+ // Create simple square pattern
+ const stitches = [
+ [0, 0, 0, 0],
+ [100, 0, 0, 0],
+ [100, 100, 0, 0],
+ [0, 100, 0, 0],
+ ];
+ const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
+
+ // Rotate 45°
+ const rotated = transformStitchesRotation(stitches, 45, bounds);
+
+ // Encode to PEN
+ const encoded = encodeStitchesToPen(rotated);
+
+ // Decode back
+ const decoded = decodePenData(new Uint8Array(encoded.penBytes));
+
+ // Verify stitch count preserved (note: lock stitches are added)
+ expect(decoded.stitches.length).toBeGreaterThan(0);
+ });
+
+ it("should handle rotation with multiple colors", () => {
+ const stitches = [
+ [0, 0, 0, 0],
+ [100, 0, 0, 0],
+ [100, 100, 0, 1], // Color change
+ [0, 100, 0, 1],
+ ];
+ const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
+
+ const rotated = transformStitchesRotation(stitches, 90, bounds);
+ const encoded = encodeStitchesToPen(rotated);
+ const decoded = decodePenData(new Uint8Array(encoded.penBytes));
+
+ // Verify color blocks preserved
+ expect(decoded.colorBlocks.length).toBeGreaterThan(0);
+ });
+
+ it("should handle negative coordinates after rotation", () => {
+ const stitches = [
+ [0, 0, 0, 0],
+ [100, 0, 0, 0],
+ ];
+ const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
+
+ // Rotate 180° will produce negative coordinates
+ const rotated = transformStitchesRotation(stitches, 180, bounds);
+
+ // Encode to PEN
+ const encoded = encodeStitchesToPen(rotated);
+ const decoded = decodePenData(new Uint8Array(encoded.penBytes));
+
+ // Should not crash and should produce valid output
+ expect(decoded.stitches.length).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/src/utils/rotationUtils.ts b/src/utils/rotationUtils.ts
new file mode 100644
index 0000000..9724602
--- /dev/null
+++ b/src/utils/rotationUtils.ts
@@ -0,0 +1,82 @@
+/**
+ * Rotate a single point around a center
+ */
+export function rotatePoint(
+ x: number,
+ y: number,
+ centerX: number,
+ centerY: number,
+ angleDegrees: number,
+): { x: number; y: number } {
+ const angleRad = (angleDegrees * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+
+ const dx = x - centerX;
+ const dy = y - centerY;
+
+ return {
+ x: centerX + dx * cos - dy * sin,
+ y: centerY + dx * sin + dy * cos,
+ };
+}
+
+/**
+ * Transform all stitches by rotation around pattern center
+ */
+export function transformStitchesRotation(
+ stitches: number[][],
+ angleDegrees: number,
+ bounds: { minX: number; maxX: number; minY: number; maxY: number },
+): number[][] {
+ if (angleDegrees === 0 || angleDegrees === 360) return stitches;
+
+ const centerX = (bounds.minX + bounds.maxX) / 2;
+ const centerY = (bounds.minY + bounds.maxY) / 2;
+
+ return stitches.map(([x, y, cmd, colorIndex]) => {
+ const rotated = rotatePoint(x, y, centerX, centerY, angleDegrees);
+ return [Math.round(rotated.x), Math.round(rotated.y), cmd, colorIndex];
+ });
+}
+
+/**
+ * Calculate axis-aligned bounding box of rotated bounds
+ */
+export function calculateRotatedBounds(
+ bounds: { minX: number; maxX: number; minY: number; maxY: number },
+ angleDegrees: number,
+): { minX: number; maxX: number; minY: number; maxY: number } {
+ if (angleDegrees === 0 || angleDegrees === 360) return bounds;
+
+ const centerX = (bounds.minX + bounds.maxX) / 2;
+ const centerY = (bounds.minY + bounds.maxY) / 2;
+
+ // Rotate all four corners
+ const corners = [
+ [bounds.minX, bounds.minY],
+ [bounds.maxX, bounds.minY],
+ [bounds.minX, bounds.maxY],
+ [bounds.maxX, bounds.maxY],
+ ];
+
+ const rotatedCorners = corners.map(([x, y]) =>
+ rotatePoint(x, y, centerX, centerY, angleDegrees),
+ );
+
+ return {
+ minX: Math.min(...rotatedCorners.map((p) => p.x)),
+ maxX: Math.max(...rotatedCorners.map((p) => p.x)),
+ minY: Math.min(...rotatedCorners.map((p) => p.y)),
+ maxY: Math.max(...rotatedCorners.map((p) => p.y)),
+ };
+}
+
+/**
+ * Normalize angle to 0-360 range
+ */
+export function normalizeAngle(degrees: number): number {
+ let normalized = degrees % 360;
+ if (normalized < 0) normalized += 360;
+ return normalized;
+}
Served by rngit 1.3.3 - Generated in 0.07s